Skip to content

Avoid Piece creation in BaseBoard.board_fen() for 108% speedup#1182

Open
jacksonthall22 wants to merge 3 commits intoniklasf:masterfrom
hyprchs:master
Open

Avoid Piece creation in BaseBoard.board_fen() for 108% speedup#1182
jacksonthall22 wants to merge 3 commits intoniklasf:masterfrom
hyprchs:master

Conversation

@jacksonthall22
Copy link
Copy Markdown
Contributor

@jacksonthall22 jacksonthall22 commented Mar 24, 2026

Currently BaseBoard.board_fen() calls self.piece_at() for each square while building the board FEN, which creates a Piece object only to get its piece.symbol(). I've identified that ephemeral object creation as a major speed bottleneck in some code I'm writing for Hyperchess to extract all unique FENs from the Lichess open database.

This PR adds a block which skips the intermediate Piece creation and sets the piece symbol manually by checking the square's piece with fast checks against self's bitboards (.pawns, .knights, etc.), after checking that neither piece_at nor piece_type_at have been overridden on the board instance (e.g. the change should not skip the existing path that relies on those methods if self is some MyCustomBoard that inherits from Board/BaseBoard and defines its own piece_at/piece_type_at).

Also moves this block up to get the promoted-piece mask once per call instead of once per occupied square:

                if promoted is None:
                    promoted_mask = self._effective_promoted()
                elif promoted:
                    promoted_mask = self.promoted
                else:
                    promoted_mask = BB_EMPTY

Disclaimer: code was written with AI and after review I agree it's sound.

Benchmark

This script records time spent inside board.board_fen() and was used to generate the before/after table below:

import time

import chess
import chess.pgn

PATH_TO_PGN = "/path/to/file.pgn"
# https://database.lichess.org/standard/lichess_db_standard_rated_2026-02.pgn.zst
# PATH_TO_PGN = "lichess_db_standard_rated_2013-01.pgn"
MAX_GAMES = 5000

games = []
with open(PATH_TO_PGN, "r") as f:
    while len(games) < MAX_GAMES:
        game = chess.pgn.read_game(f)
        if game is None:
            break
        games.append(game)

num_positions = 0
total_elapsed = 0
for game in games:
    board = game.board()
    for move in game.mainline_moves():
        board.push(move)

        t0 = time.perf_counter()
        board.board_fen()
        total_elapsed += time.perf_counter() - t0

        num_positions += 1

calls_per_s = num_positions / total_elapsed

print(f"games={len(games)} num_positions={num_positions}")
print(f"total_elapsed={total_elapsed:.3f}")
print(f"calls_per_s={calls_per_s:.2f}")
Metric Before After Change
total_elapsed 6.888s 3.298s -52.1%
calls_per_s 47314.46 98802.75 +108.8%

@jacksonthall22 jacksonthall22 changed the title Avoid Piece creation in BaseBoard.board_fen() for 52% speedup Avoid Piece creation in BaseBoard.board_fen() for 108% speedup Mar 24, 2026
@robertnurnberg
Copy link
Copy Markdown
Contributor

Just as a side remark: extracting all unique FENs from a large collection of (compressed) pgn files can be efficiently done with vondele/fastpopular. The main branch checks for uniqueness up to Zobrist hash collisions. Full uniqueness can easily be achieved if desired (at the cost of a larger memory footprint), see e.g. here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants